笔记
欢迎大家回到B端项目的开发,这周让我们着眼于编辑器右侧设置部分的编码工作,完成组件属性设置,图层设置,以及页面设置的一系列功能。为了不让大家陷入业务的泥潭中,会挑选典型的几个业务和知识点讲行讲术和开发
将收获什么
裁剪图片的实现
- 借助阿里云OSS的图片处理
- 重新获取裁剪的图片数据并且重新上传
创建Vue3钩子函数的原则
拖动排序的实现原理
复杂正则表达式的分析过程
主要内容
- 使用Cropper.js 完成图片裁剪功能
- 创建LayerList组件以及完成InlineEdit 组件的编码
- 自研一个简单的列表拖动方案,了解原理后,然后使用Vue Draggble Next进行替换
- 完成 EditGroup 的编码,了解使用伪代码分析问题的过程
- 完成BackgroundProcesser组件,学习正则表达式的分析过程
学习方法
- 先了解原理,自己简单实现,然后可以使用成熟的第三方工具
- 使用伪代码或者趁手的第三方工具,帮助完成复杂的数据转换的分析
1. 使用 CropperJs 裁剪库
选择图片裁剪工具
寻找合适开源库的经验之谈
- 使用google或者github 来搜索
- 使用英文作为关键词
考量一个开源库是否符合标准
- star 数量
- issue数量
- releases活跃度
- 查看它的DEMO
初始化 Cropper 区域
<a-modal
:width="1000"
title="裁剪图片"
v-model:visible="showModal"
@ok="handleOk"
@cancel="showModal = false"
okText="确认"
cancelText="取消">
<div class="image-cropper">
<img id="processed-image" ref="cropperImageRef" :src="baseImageUrl" alt="">
</div>
</a-modal>
<a-button @click="showModal = true">
<template v-slot:icon><ScissorOutlined /></template>裁剪图片
</a-button>
<a-button v-if="showDelete" type="danger" @click="handleDelete">
<template v-slot:icon><DeleteOutlined /></template>删除图片
</a-button>
const showModal = ref(false)
const backgroundUrl = computed(()=>`url(${props.value})`)
const baseImageUrl = computed(()=> props.value.split('?')[0])
const cropperImageRef = ref<null|HTMLImageElement>(null)
let cropper: Cropper;
let cropData: CropDataProps | null = null;
watch(showModal, async(newValue)=>{
if(newValue) {
// 等待 dom 加载完再获取
await nextTick()
console.log(cropperImageRef.value);
}else {
cropper && cropper.destroy()
}
})
const handleOk = () => {
showModal.value = false
}
方法一:使用阿里云 OSS 完成图片裁剪
watch(showModal, async(newValue)=>{
if(newValue) {
// 等待 dom 加载完再获取
await nextTick()
console.log(cropperImageRef.value);
if(cropperImageRef.value){
cropper = new Cropper(cropperImageRef.value, {
// aspectRatio: 16 / 9,
crop(event) {
console.log(event.detail);
const {x,y,width,height} = event.detail
cropData = {
x: Math.floor(x),
y: Math.floor(y),
width: Math.floor(width),
height: Math.floor(height),
}
},
});
}
}else {
cropper && cropper.destroy()
}
})
const handleOk = () => {
if(cropData) {
const {x,y,width,height} = cropData
// 使用阿里OSS裁剪图片
// 每次只能对原图进行裁剪,否则坐标位置会发生偏移
const cropperURL = `${baseImageUrl.value}?x-oss-process=image/crop,x_${x},y_${y},w_${width},h_${height}`
emit('change',cropperURL)
}
showModal.value = false
}
使用 Cropper.js 获取裁剪图片数据
- 实例上的一个方法 - getCroppedCanvas
https://github.com/fengyuanchen/cropperjs#getcontainerdata
- HTMLCanvasElement
https://developer.mozilla.org/zh-TW/docs/Web/API/HTMLCanvasElement
2. 分析图层设置的需求和实现
图层属性需求分析
图层锁定和隐藏/显示以及点击选中
- 在editor.ts的store 中的components添加更多标识符
{
...
isLocked: boolean ;
isHidden : boolean ;
}
- 点击按钮切换为不同的值,使用这个值在页面上做判断。
- 点击选中,设置currentElement的值
图层名称编辑
添加更多属性-layerName
点击图层名称的时候,在input和普通标签之间切换。
添加按钮响应-对于esc和enter键的响应
- 可能抽象一个通用的hooks函数– useKeyPress
点击到input外部区域的响应
- 可能抽象一个通用的hooks函数- useClickOutside
拖动改变顺序
- 最有难度的一个需求,涉及到一个较复杂的交互
- 最终目的其实就是改变store中components 数组的顺序
- 在这块功能进行编码的时候,再开始详细的分析
图层列表设置锁定
图层列表设置选中
图层列表设置隐藏/显示
图层重命名
InLineEdit组件
- 显示默认文本区域,点击以后显示为Input
- Input中的值显示为文本中的值
- 更新值以后,键盘事件- (useKeyPress)
- 点击回车以后恢复文本区域,并且显示新的值
- 点击ESC后恢复文本区域,并且显示刚开始的旧的值
- 更新值以后,点击事件- ( useCl ickOutside )
- 点击Input区域外侧恢复文本区域,并且显示新的值
- 简单验证
- 当Input 值为空的时候,不恢复,并且显示错误。
知识点
使用事件派发enter事件
const event = new KeyboardEvent('keydown', { key: 'Escape' })
document.dispatchEvent(event)
写测试用例
import { mount, VueWrapper } from "@vue/test-utils";
import InlineEdit from "@/components/InlineEdit.vue";
import { nextTick } from 'vue'
let wrapper: VueWrapper<any>;
describe("InlineEdit component", () => {
beforeAll(() => {
wrapper = mount(InlineEdit, {
props: {
value: "test",
},
slots: {
default: '<template #default="{ text }"><h2>{{text}}</h2></template>'
}
});
});
it('should render the default layout', () => {
expect(wrapper.get('h2').text()).toBe('test')
})
it('should render input when clicking the element', async () => {
await wrapper.trigger('click')
expect(wrapper.find('input').exists()).toBeTruthy()
const input = wrapper.get('input').element as HTMLInputElement;
expect(input.value).toBe('test')
})
it('press enter should render to default layout with new value', async () => {
const newText = 'testnew'
await wrapper.get('input').setValue(newText)
// 全局调用事件
const event = new KeyboardEvent('keydown', { key: 'Enter' })
document.dispatchEvent(event)
await nextTick()
expect(wrapper.find('h2').exists()).toBeTruthy()
expect(wrapper.get('h2').text()).toBe(newText)
const events: any = wrapper.emitted('change')
expect(events[0]).toEqual([newText])
})
it('press esc should render to default layout with old value', async () => {
const newText = 'test123'
await wrapper.trigger('click')
await wrapper.get('input').setValue(newText)
const event = new KeyboardEvent('keydown', { key: 'Escape' })
document.dispatchEvent(event)
await nextTick()
expect(wrapper.find('h2').exists()).toBeTruthy()
expect(wrapper.get('h2').text()).toBe('testnew')
})
it('click outside should render to default layout with new value', async () => {
await wrapper.trigger('click')
await wrapper.get('input').setValue('testupdated')
const event = new MouseEvent('click')
document.dispatchEvent(event)
await nextTick()
expect(wrapper.find('h2').exists()).toBeTruthy()
expect(wrapper.get('h2').text()).toBe('testupdated')
const events: any = wrapper.emitted('change')
expect(events[1]).toEqual(['testupdated'])
})
});
useKeyPress.ts
import { onMounted, onUnmounted } from 'vue';
const useKeyPress = (key: string, cb: () => any) => {
const trigger = (event: KeyboardEvent) => {
if (event.key === key) {
cb()
}
}
onMounted(() => {
document.addEventListener('keydown', trigger)
})
onUnmounted(() => {
document.removeEventListener('keydown', trigger)
})
}
export default useKeyPress
useClickOutside.ts
import { ref, onMounted, onUnmounted, Ref } from 'vue';
/**
* 点击元素是否在当前区域内
* @param elementRef
*/
const useClickOutside = (elementRef: Ref<null | HTMLElement>) => {
const isClickOutside = ref(false); // 是否点击了区域外
const handler = (e: MouseEvent) => {
if (elementRef.value && e.target) {
// 检查点击元素是否在当前区域内
// 包含在区域内
// 类型“EventTarget”的参数不能赋给类型“Node”的参数 可以使用类型断言,把父类断言成子类
if (elementRef.value.contains(e.target as HTMLElement)) {
isClickOutside.value = false
} else {
isClickOutside.value = true
}
}
}
onMounted(() => {
document.addEventListener('click', handler)
})
onUnmounted(() => {
document.removeEventListener('click', handler)
})
return isClickOutside
}
export default useClickOutside
InLineEdit.vue
<!-- -->
<template>
<div class="inline-edit" @click.stop="handleClick" ref="wrapper">
<input v-if="isEditing" v-model="innerValue" placeholder="文本不能为空" ref="inputRef" />
<slot v-else :text="innerValue">
<span>{{ innerValue }}</span>
</slot>
</div>
</template>
<script lang='ts'>
import { defineComponent, nextTick, ref, watch } from 'vue'
import useKeyPress from "@/hooks/useKeyPress";
import useClickOutside from "@/hooks/useClickOutside";
export default defineComponent({
name: 'inline-edit',
props: {
value: {
type: String,
requiered: true,
}
},
emits: ['change'],
setup(props, context) {
const innerValue = ref(props.value)
const wrapper = ref<null | HTMLElement>(null)
const inputRef = ref<null | HTMLInputElement>(null)
const isOutside = useClickOutside(wrapper)
let cachedOldValue = ''
const isEditing = ref(false)
const handleClick = () => {
isEditing.value = true
}
watch(isEditing, async (isEditing) => {
if (isEditing) {
cachedOldValue = innerValue.value || ''
await nextTick() // 此时还没有获取 dom 节点
if(inputRef.value) { // 获取input,添加聚焦
inputRef.value.focus()
}
}
})
watch(isOutside, (newValue) => {
if (newValue && isEditing.value) {
isEditing.value = false
context.emit('change', innerValue.value)
}
isOutside.value = false
})
useKeyPress('Enter', () => {
if (isEditing.value) {
isEditing.value = false
context.emit('change', innerValue.value)
}
})
useKeyPress('Escape', () => {
if (isEditing.value) {
isEditing.value = false
innerValue.value = cachedOldValue
}
})
return {
innerValue,
isEditing,
handleClick,
wrapper,
inputRef
};
}
})
</script>
<style lang='scss' scoped>
.inline-edit {
cursor: pointer;
}
.ant-input.input-error {
border: 1px solid #f5222d;
}
.ant-input.input-error:focus {
border-color: #f5222d;
}
.ant-input.input-error::placeholder {
color: #f5222d;
}
</style>
拖拽排序列表
新的学习方法
- 用手写简单的方法实现一个功能。
- 然后使用比较成熟的第三方解决方案。
- 既能学习原理又能学习第三方库的使用。
从两个 demo 开始
- vue Draggable Next: https://sortablejs.github.io/vue.draggable.next/#/simple
- React Sortable HoC: https://clauderic.github.io/react-sortable-hoc/
列表排序的三个阶段
拖动开始 (dargstart)
- 被拖动图层的状态变化
- 会出现一个浮层
拖动进行中(dragmove)
- 浮层会随着鼠标移动
- 条目发生换位:当浮层下沿超过被拖动条目二分之一的时候,触发换位
松开鼠标阶段(drop)
- 浮层消失
- 被拖动图层状态还原
- 数据被更新
第一阶段 Dragstart
被拖动图层的状态变化 常规做法
- 添加mouseDown事件,检查当前的 target是哪个元素,然后给他添加特定的状态
- 添加mouseMove事件,创建一个和被拖动元素一摸一样的浮层,将它的定位设置为绝对定位,并且随着鼠标的坐标更新。
使用 HTML 的 Drag 特性
- https://developer.mozilla.org/zh-CN/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations
- 浏览器的默认拖拽行为:支持图像,链接和选择的文本。
- 其他元素默认情况是不可拖拽的。
- 如果想可以拖拽可以设置为draggable = 'true
- 使用dragstart 事件监控拖动开始,并设置对应属性
原生拖拽事件实现
<!-- 图层列表 -->
<template>
<ul
:list="list"
class="ant-list-items ant-list-bordered"
@drop="onDrop"
@dragover="onDragOver"
>
<li
class="ant-list-item"
v-for="(item,index) in list" :key="item.id"
:class="{active: item.id === selectedId, ghost: dragData.currentDragging === item.id}"
@click="handleClick(item.id)"
:data-index="index"
draggable="true"
@dragstart="onDragStart($event,item.id,index)"
@dragenter="onDragEnter($event, index)"
>
<a-tooltip :title="item.isHidden ? '显示' : '隐藏'">
<a-button shape="circle" @click.stop="handleChange(item.id, 'isHidden', !item.isHidden)">
<template v-slot:icon v-if="item.isHidden">
<EyeOutlined />
</template>
<template v-slot:icon v-else>
<EyeInvisibleOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip :title="item.isLocked ? '解锁' : '锁定'">
<a-button shape="circle" @click="handleChange(item.id,'isLocked', !item.isLocked)">
<template v-slot:icon v-if="item.isLocked">
<UnlockOutlined />
</template>
<template v-slot:icon v-else>
<LockOutlined />
</template>
</a-button>
</a-tooltip>
<inline-edit class="edit-area" :value="item.layerName" @change="(value) => {handleChange(item.id, 'layerName', value)}"></inline-edit>
</li>
</ul>
</template>
<script lang='ts'>
import { PropType, reactive } from 'vue';
import { arrayMoveMutable } from 'array-move'
import { EyeOutlined, EyeInvisibleOutlined, UnlockOutlined, LockOutlined } from "@ant-design/icons-vue";
import { ComponentData } from '@/store/editor';
import InlineEdit from '@/components/InlineEdit.vue'
import { getParentElement } from '@/helper';
export default {
name: '',
components: {
EyeOutlined, EyeInvisibleOutlined, UnlockOutlined, LockOutlined,InlineEdit
},
props: {
list: {
type: Array as PropType<ComponentData[]>,
required: true,
},
selectedId: {
type: String,
required: true,
}
},
emits: ['select', 'change','drop'],
setup(props:any, ctx:any) {
const dragData = reactive({
currentDragging: '',
currentIndex: -1
})
let start = -1
let end = -1
const handleClick = (id:string) => {
ctx.emit('select',id)
}
const onDragStart = (e:DragEvent,id: string, index:number) => {
dragData.currentDragging = id
dragData.currentIndex = index
start = index
}
const onDragEnter = (e: DragEvent, index:number) => {
if(index !== dragData.currentIndex) {
console.log('enter', index, dragData.currentIndex);
arrayMoveMutable(props.list, dragData.currentIndex, index) // 修改原数组
dragData.currentIndex = index
end = index
}
}
const onDrop = (e:DragEvent) =>{
ctx.emit('drop',{start,end})
dragData.currentDragging = ''
}
const onDragOver = (e:DragEvent) =>{
e.preventDefault()
}
const handleChange = (id: string,key: string,value: boolean) => {
const data = {
id,
key,
value,
isRoot: true
}
ctx.emit('change',data)
}
return {
handleChange,
handleClick,
onDragStart,
dragData,
onDrop,
onDragOver,
onDragEnter
}
}
}
</script>
<style lang='scss' scoped>
.ant-list-item {
padding: 10px 15px;
transition: all 0.5s ease-out;
cursor: pointer;
justify-content:normal;
border: 1px solid #fff;
border-bottom-color: #f0f0f0;
&.ghost {
opacity: 0.5;
}
}
.ant-list-item.active {
border: 1px solid #1890ff;
}
.ant-list-item:hover {
background:#e6f7ff
}
.ant-list-item>* {
margin-right: 10px;
}
.ant-list-item button {
font-size: 12px;
}
</style>